
定義表達得了的行為,一行程式都不必寫。表達不了的那些才需要接手:這張單據送出前要檢查什麼、存完之後要通知誰、刪除前什麼情況不准刪。框架給的接手方式是繼承業務物件,覆寫其中幾個步驟。
子類別依需求在對應的步驟加進這張表單自己的邏輯,難的是判斷「對應的」是哪一步,以及那段程式該寫在 base 的前面還是後面。方法名稱只答得出順序,DoBeforeSave 在存檔之前、DoAfterSave 在存檔之後,其餘要由框架主動講明。
本篇說明:
Save 為什麼要拆成好幾個步驟base 那一行前後的差異Day 3 那八張表單裡七張的應用程式碼是零行,只有訂單那一張指定了自己的型別。增修刪查與驗證都由定義長出來,會需要寫程式的是定義表達不了的那一段。
所以繼承之前有三層要先問過,順序由淺到深:
| 這件事能不能… | 交付方式 | 誰改得動 |
|---|---|---|
| 寫成欄位上的運算式或一條規則(Day 8) | 改一份定義檔 | 懂業務的人 |
| 寫成一個掛在時點上的 plugin(Day 21) | 換一個組件 | 開發者 |
| 都不行,才繼承業務物件 | 換一個組件,改掉這張表單綁的型別 | 開發者 |
三層的差別不在難度,在交付路徑:愈上面的愈能跟著定義一起走,愈下面的愈接近「回到程式碼那一側」。下面那兩層都得出一個組件,而 plugin 只在固定的時點加一段,套裝原本那一段一定會跑;繼承手上有 base 那一行,套裝那一段留不留由子類決定。這條軸線是 Day 30 的骨幹,這裡只取一個推論:繼承是最後一層。
判別法沿用 Day 2 那條:這件事全世界的 ERP 做起來是不是都一樣?一樣的遲早會被收成機制,不一樣的才該由應用寫。訂單的狀態轉換規則每一家都不同,那是繼承的正當理由;至於總計等於明細加總,它現在寫在應用裡只是因為框架還沒收。
以套裝請假單為例,假別選特休,存檔之前就要驗他還剩幾天、夠不夠這次請,而那是假單自己的邏輯,所以套裝假單要繼承框架的業務物件基底下來改寫。客製再往下接一層:某一家客戶算特休的方式跟套裝不一樣,就繼承套裝這個假單的業務物件再改寫一次,把套裝那一段驗證換掉。同一個機制接了兩層,兩種理由。
繼承的入口是註冊表上那一列,指定 ProgId 對應到哪一個型別(Day 15 的題目)。宣告之後框架建出來的就是你的子類,覆寫的那幾個步驟會被叫到,沒覆寫的照原樣跑。
Save 為什麼要拆成好幾個步驟假設框架只給一支 Save 讓人覆寫。應用想在存檔前擋一筆資料,就得把整支重寫,而那一支裡有授權檢查、資料範圍檢查、規則求值、持久化、稽核寫入。要加三行,得先把這五件事原樣抄回來,抄漏一件就是一個安靜的漏洞。
拆開之後,覆寫的人只接手自己那一段。寫入面能動的是六個 protected virtual 步驟:
Save: DoBeforeSave → DoSave → [變更稽核] → DoAfterSave
Delete: DoBeforeDelete → DoDelete → [刪除稽核] → DoAfterDelete
↑
這兩個步驟在 transaction 內
Save 的骨架節錄在 FormBusinessObject.Write.cs 裡,順序一眼可讀:
DoBeforeSave(context);
plugins.RunBeforeSave(context);
// ...擷取變更集(改動前/改動後)供稽核使用
DoSave(context); // transaction 在這一句之內開啟並 commit
if (auditChange) WriteChangeAudit(...);
DoAfterSave(context);
plugins.RunAfterSave(context);
DoSave 與 DoDelete 為什麼各自是一條 transactiontransaction 由 Repository 在 DoSave 內部開與關,涵蓋的是整筆單據:主檔一句、明細有幾列就有幾句,全部成功才 commit,任何一句失敗就整筆退回。存進去的必須是一張完整的單,不能是主檔在而明細少了幾列。
刪除要的是同一件事的另一半:主檔刪掉了,底下的明細也必須跟著消失,否則留下的是一批對不到主檔的明細列。這兩種殘缺 Day 11 都講過,共同點是資料庫不會替你擋,因為框架不建外鍵約束。形狀也在那裡:存檔主檔先、明細後,刪除反過來。
所以存檔與刪除各有一步落在裡面:DoSave 與 DoDelete 是六段裡僅有的兩個帶原子性承諾的步驟,其餘四段都在外面。
擷取改動前後的值排在 DoSave 之前,因為持久化走的是 ADO.NET 的 DataAdapter,成功之後它會把每一列標成已接受,原值與列狀態一起被丟掉。寫入稽核那一列則在 DoSave 之後,要等資料真的存進去才有意義。
擷取點的另一邊也被釘住:它排在 DoBeforeSave 之後,計算欄與規則填上去的值才進得了稽核。一個位置被兩側同時釘住,通常就改不動了,而看程式碼的順序看不出它為什麼在那裡。
代價是「資料寫成功、稽核寫失敗」可能發生,框架接受了這個落差。前面那個決定不是這樣:整筆單據的語句要嘛一起算數、要嘛一起不算,框架不給部分成功。兩個決定相反,因為壞掉的方式不同。
| 壞掉的後果 | 決定 | |
|---|---|---|
| 主檔與明細 | 留下一張明細不齊的單,而且不會報錯 | 強制同 transaction |
| 變更稽核 | 少一列記錄,資料本身是對的 | 接受非原子 |
原子性不是越多越好,它有價格,要逐項決定付不付。稽核本身的形狀是 Day 25 的題目。
| 步驟 | 適合放什麼 |
|---|---|
DoBeforeSave |
填預設值、算跨列的合計、擋明顯錯誤的輸入 |
DoSave |
必須與這筆資料同生共死的寫入 |
DoAfterSave |
通知、呼叫外部系統、丟進佇列 |
DoBeforeDelete |
依刪除前的整筆資料判斷准不准刪 |
DoDelete |
要跟著一起刪掉的其他資料 |
DoAfterDelete |
把刪除這件事傳給下游 |
要接手就覆寫這六個之一,不要覆寫 public 的 Save / Delete,否則就回到上一節開頭那個處境:授權、資料範圍、稽核那幾段全部得自己原樣抄回來。讀取那一側同樣有幾個開好的位置,最常用到的是取一筆新資料時填預設值,以及替開窗選取的候選列收窄,例如只列出還在啟用中的。
以存檔那一側為例,各段依序要付的代價是:
| 若把這一段納入 | 代價 |
|---|---|
DoBeforeSave |
規則求值、跨表關連的查詢、呼叫其他業務物件全部在鎖內。一張表單的規則越多,鎖就抓得越久 |
| 稽核寫入 | 每次存檔都多一列必須同時成功的寫入。軌跡那一側出問題,變成業務單據存不進去 |
DoAfterSave |
外部系統的延遲直接變成鎖持有時間。對方逾時,等於這裡的連線被佔著不放 |
這在 ERP 上特別要緊。單據之間關連得密,一次存檔動到的常常不只自己那一張表,於是兩個看起來無關的功能會落在同一批列上:這邊的出貨單還沒放掉庫存那幾列,那邊的成本計算就得等。鎖持有的時間越長,這種互相卡住就越常發生。
所以拆成六段是把「必須原子」的範圍壓到最小,transaction 裡只有寫入。其餘每一段都可以慢,慢不會傷到別人。
DoAfterSave 丟例外時,資料已經寫進去了在 DoAfterSave 丟例外,例外會往上拋、該次呼叫回報失敗,但這一段開始之前資料已經寫進去了。使用者看到「存檔失敗」,資料庫裡那筆單據卻在,而且是完整的。所以放在這裡的副作用要能被重試,或交給佇列而不是同步執行;同步送出一個通知然後失敗,就沒有任何東西可以重試(不同可靠性等級各該用哪一種,是 Day 21 的題目)。
base 那一行決定你看到什麼自己的邏輯寫在 base 前面還是後面,看它需要什麼。那一行不是形式,它決定你手上這份資料處在什麼狀態,而六個步驟的 base 做的事各不相同:
| 步驟 | base 做的事 |
呼叫之後多了什麼 |
|---|---|---|
DoBeforeSave |
套用預設值與計算欄運算式,再跑 BeforeSave 驗證規則 |
算完的值。寫在它前面看到的是呼叫端送上來的原樣 |
DoBeforeDelete |
拿刪除前的快照跑 BeforeDelete 規則 |
同上,而且它讀的是快照,不是送上來的資料 |
DoSave / DoDelete |
整筆持久化 | 資料已經寫進去,transaction 已經 commit |
DoAfterSave / DoAfterDelete |
什麼都不做 | 沒有差別,前後等價 |
DoBeforeSave 是最常覆寫的位置,也最容易放錯邊。計算欄的值是 base 那一行填上去的,自訂檢查要看得到它就得排在後面;反過來,要在規則跑之前先塞一個值進去,那才是該寫在前面的東西。
三個步驟共用一個 SaveContext,它從頭傳到尾,省掉每一段各自去解析 Repository 與 FormSchema。但同一個物件在不同位置的內容不同:
SaveContext 的成員 |
DoBeforeSave |
DoAfterSave |
|---|---|---|
Args / DataSet / Repository / Schema |
有 | 有 |
RefreshedDataSet |
null |
持久化後回寫的那一份 |
AffectedRows |
空 | 每張表的異動列數 |
DataSet 兩邊都拿得到,可以做的事卻相反。在前面那個位置改它,改動會跟著寫進資料庫;在後面那個位置改它沒有任何作用,要影響呼叫端收到什麼得改 RefreshedDataSet。同一個屬性,一邊是輸入、一邊是紀錄。
刪除那一側另有一份快照,內容是刪除前的整筆資料(主檔加明細),供 BeforeDelete 規則比對、也供稽核記下改動前的內容。它有兩個性質:
null。第二點是擴充點設計的通則:一個擴充點手上拿得到什麼,不該取決於另一個功能有沒有開。
反過來問:框架決定要不要開一個新位置的時候,是照什麼判的?
判準只有一條:有實際需求才開。現在沒開的那些,多數不是不能開,是還沒有人真的需要。理由在於它不可逆:擴充點是對外承諾,開放之後有人覆寫了就收不回來,而套裝軟體的維護期十年二十年起跳。
一種是設計上就不開。授權、資料範圍與稽核那幾支全部是 private,判別法是這一段的存在理由是不是「不管誰來都要跑」,是的話開放覆寫等於把它變成可選的。
另一種只是還沒有需求,數量遠比第一種多,隨時可以變成擴充點。把第二種說成「框架不支援」會讓人去繞路,而繞路留下的東西比一個晚一步開出來的擴充點難收拾得多。
開在最小的那一層。開窗選取的候選列,整段查詢都可以覆寫,但框架另外開了一個小位置,只讓你加一個條件把候選列收窄,因為多數人要的就是這個。
還有一種做法是把入口和實作分開:入口不開放覆寫,只負責呼叫另一支開放覆寫的實作方法,覆寫錯那一支根本編不過。Save 與 Delete 沒有這樣做,所以「不要覆寫這一支」只能靠文件說。
所以現在開著的每一個位置,背後都有一個當時真的存在的需求;沒開的那一批,等需求收斂出新的共通行為,就會再長出對應的位置。
案例的 OrderBO 覆寫了兩個步驟,寫入面的那個是 DoBeforeSave,裡面做四件事。逐件問「放在這個位置對不對」,答案不一樣:
| 這一件 | 要不要問資料庫 | 放在這裡 |
|---|---|---|
| 至少一列明細 | 否 | 可以,純記憶體判定 |
| 主檔總額等於明細加總 | 否 | 可以,純記憶體計算 |
| 狀態轉換是否合法 | 是(讀存量狀態) | 有空窗 |
| 訂單編號配號 | 是(讀當月最大值) | 有空窗 |
前兩件的輸入全部在手上那份 DataSet 裡,位置正確。後兩件都要回頭問資料庫,於是讀完到 DoSave 寫進去之間留了一段空窗,另一個人可能已經在那段時間裡把前提改掉。
訂單編號那一件的形狀最單純,程式在 OrderBO 裡:
var currentMax = Repository().GetMaxOrderNumber($"ORD-{yearMonth}-%");
master["sys_id"] = OrderRules.NextOrderNumber(yearMonth, currentMax);
查當月最大值加一,兩個人同時開單就會拿到同一個號碼。擋下它的不是這段程式,是資料庫:
CREATE UNIQUE INDEX "uk_ft_order" ON "ft_order" ("sys_id" ASC)
第二筆 INSERT 在 DoSave 內被拒絕,整筆 rollback。這個索引不是為了這件事特地加的,sys_id 是框架的系統欄位,由 FormSchema 產生 TableSchema 時就自動配上了。防線確實落在寫入的當下,只是它不是程式寫出來的,是定義宣告出來的;代價是撞號的那個使用者要重存一次。
狀態轉換那一件沒有索引可以擋:兩個人同時把同一張單從草稿確認掉,兩邊讀到的都是草稿,兩邊都判定合法,而分開看兩次寫入也都沒有錯,只是不該同時發生。要擋住得把「目前狀態必須還是草稿」寫進那句 UPDATE,案例沒有做到這一層。
擴充點的位置決定它的語意,而位置不會寫在方法名稱上:
Before 說的是順序,不是保護。這裡讀到的值只在讀的那一刻成立。DoSave 與 DoDelete 是與資料同生共死的那一段,而要把一條規則放進去,靠的多半不是覆寫它們,是讓資料庫自己在寫入當下判定。After 之後,資料已經是別人看得到的事實了。可以帶走的判準:框架開一個擴充點的時候,除了「什麼時候跑」,還欠使用者一句「它在 transaction 的哪一邊」。少了那一句,覆寫的人會照著方法名稱推論,而名稱推論不出邊界。
這句話對所有掛在流程上的機制都成立。哪一段在裡面、哪一段在外面決定的不只是效能,是那段程式能不能承諾它剛剛看到的東西還算數。
明天談這些擴充點掛在哪一個型別上:ProgId、合約分層與型別註冊表。
本系列同步發表於 HackMD,完整目錄